Skip to content

Blob: return only the slice when consuming a slice's stream after the Blobs were collected - #38685

Merged
dylan-conway merged 4 commits into
mainfrom
farm/20904d2f/blob-slice-stream-whole-store
Oct 2, 2026
Merged

dylan-conway merged 4 commits into
mainfrom
farm/20904d2f/blob-slice-stream-whole-store

Conversation

@robobun

@robobun robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A slice read through a stream returns the whole parent Blob once the Blob objects are collected. await new Response(parent.slice(7, 13).stream()).text() gives "SECRET-public-SECRET", not "public".
  • Any::to_internal_blob_if_possible (src/runtime/webcore/Blob.rs:6034) moves the whole buffer out of a store with one reference. It ignores the Blob's offset and size.
  • Keep body stream bookkeeping independent of the body's source #42116 widened it on main: a body keeps a Blob-backed stream, and text() reads it this way. Bun.readableStreamToText(slice.stream()) was already wrong in 1.4.0.

Fix

  • to_internal_blob_if_possible also requires offset == 0 && size >= store length. A windowed Blob stays an Any::Blob.
  • Correct because the Any::Blob arms of to_action_value apply the window. The same calls take that path while the parent is alive.
  • Verified: test/js/web/fetch/blob.test.ts (consuming a slice's stream). 31 of 37 cases fail on the unfixed 1.4.3 canary, all pass here. Notes list the other suites.

Background

  • A Blob's bytes live in a refcounted Store. slice() shares it and narrows offset and size. Each Blob object holds one reference until it is finalized.
  • blob::Any is what a buffered stream consumer reads. Any::Blob views a shared store. Any::InternalBlob owns a Vec<u8> that JS adopts without a copy.
  • Considered a to_blob_if_possible() call in text(), as the other body getters have. It repairs the three body cases only.

Downsides

  • A slice whose stream holds the last reference is now copied (bytes) or read in place (text). Before, JS adopted the whole buffer.
  • None found for an unsliced Blob: two integer compares before the take. Checked both callers of Any::to_promise.
Notes

What reads wrong on main (4b02e10), after the parent and the slice are collected

Read main 1.4.0 to 1.4.2
Bun.readableStreamToText / ToBytes / ToArrayBuffer / ToJSON(slice.stream()) whole parent whole parent
slice.stream().text() / .json() / .bytes() whole parent whole parent
res = new Response(slice); res.body; await res.text() whole parent whole parent
await new Response(slice.stream()).text() whole parent slice
await new Response(new Response(slice).body).text() whole parent slice
await new Request(url, { method: "POST", body: res.body }).text() whole parent slice
json(), bytes(), arrayBuffer(), blob() of those bodies slice slice
Bun.readableStreamToBlob(slice.stream()) slice slice

The main column is from runs of a debug build of main and of the 1.4.3 canary (367d939). The release column is from the report and from the first revision of this PR (17 of 21 cases failed on 1.4.0).

Why only text() of a body

BodyMixin::get_text (src/runtime/webcore/Body.rs:1755) passes a Locked body's stream to readableStreamToText. The other getters call to_blob_if_possible() first, which lifts the windowed Blob back out of the stream and reads it through the Blob paths. Before #42116, Body::Value::from_js lifted the Blob out of a Blob-backed stream when the body was made, so text() never saw the stream.

The path

ByteBlobLoader::to_any_blob (src/runtime/webcore/ByteBlobLoader.rs:146) checks the window before its own whole-buffer shortcut (Store::to_any_blob). For a slice it returns a windowed Any::Blob on purpose. ByteBlobLoader::to_buffered_value passes it to Any::to_promise, then to_action_value, which ran the unguarded conversion for each action except blob(). The store's other references are the parent and the slice objects. After the GC finalizes them, has_one_ref() is true and the window was lost.

>= and not ==: shared_view clamps a size that is larger than the store, so such a Blob views all of it too.

Tests

describe("consuming a slice's stream after the Blobs were collected"), 37 cases: 3 sources (slice.stream(), new Response(slice).body, new Blob([slice]).stream()) x 8 consumers, 4 bodies read by text(), 7 windows, and two unsliced controls. The windows are a prefix, a prefix of a prefix, a suffix, a slice of a slice, an empty slice at the start, an empty slice in the middle, and slice(0). A prefix has offset 0 and a suffix ends where the store ends, so each clause of the guard has a case that depends on it. The test waits on a FinalizationRegistry for each Blob, because the finalizer is what releases the store reference.

Unfixed builds: 31 of 37 fail on the 1.4.3 canary (367d939, USE_SYSTEM_BUN=1), and 27 of the first 32 failed on a debug build of main 4b02e10. The 6 that pass are the 3 readableStreamToBlob cases (that action skips the conversion), slice(0), and the 2 unsliced controls.

Suites run on the debug build of this branch

blob.test.ts (147 pass), body.test.ts (774 pass, 4 skip), body-clone.test.ts (85), body-mixin-errors.test.ts (13), streams.test.js (624), readable-stream-blob-consumed.test.ts, blob-cow.test.ts, blob-array-fast-path.test.ts, readablestreamtoarraybuffer.test.ts, sync-pull-fast-path.test.ts. All pass. The suites other than blob.test.ts ran before the guard got its name in the last commit, which does not change what the guard does.

@coderabbitai

coderabbitai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: dad55b44-d3c2-4e27-b96b-bff7e5cd5d81
📥 Commits

Reviewing files that changed from the base of the PR and between 255bd1a and 68bbc7c.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 45b27a8b-72fe-41e9-ba74-fc9703a663bd

📥 Commits

Reviewing files that changed from the base of the PR and between 468efac and 255bd1a.

📒 Files selected for processing (2)
  • src/runtime/webcore/Blob.rs
  • test/js/web/fetch/blob.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 2 remain after this review.


Walkthrough

Byte-backed Blob conversion now requires a unique store reference and a Blob view that covers the full store. Regression tests check slice and whole-Blob stream results after source Blob collection.

Changes

Blob Slice Stream Handling

Layer / File(s) Summary
Guard internal Blob conversion
src/runtime/webcore/Blob.rs
Conversion to InternalBlob now requires a unique byte-store reference and a view that starts at offset zero and covers the store.
Test streams after Blob collection
test/js/web/fetch/blob.test.ts
Tests wait for tracked Blobs to be collected, then check slice and whole-Blob results across stream consumers, Request and Response bodies, and multiple slice windows.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 255bd

The change has no identified merge-blocking issue; it is ready for normal checks.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main fix: preserving only a Blob slice when consuming its stream after garbage collection. It is somewhat long but specific and relevant.
Description check ✅ Passed The description explains the problem, fix, technical cause, trade-offs, test coverage, and verification results. It does not use the exact template headings, but it provides the required change summar…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: rebased onto main (f4d755a), head 255bd1a. The tests cover the three body cases that are new on main, and windows at both edges of the store.

Reproduced with USE_SYSTEM_BUN=1 bun test test/js/web/fetch/blob.test.ts -t "consuming a slice's stream" on the 1.4.3 canary (367d939): 31 of 37 cases fail. A collected slice that is read through Bun.readableStreamToText, stream.text(), or text() of a Response or Request that holds the slice's stream returns the whole parent. With this branch, bun bd test test/js/web/fetch/blob.test.ts passes all 147 tests.

CI ran two times, and no red test reads a Blob:

  • Build 122731 (head 412cca9): 180 of 182 jobs passed. Red: serve-body-leak.test.ts on darwin aarch64 (ECONNRESET in a client fetch), and node-net.test.ts and sql-close-pending-connection.test.ts on the soft-fail darwin 27 lane.
  • Build 122779 (head 255bd1a): 181 of 182 jobs passed, both darwin aarch64 test jobs included. Red: socket-syscall-fault.test.ts on one of the two darwin x64 test jobs. That TLS socket test is also red in main's build 122686.

Each macOS lane passed in one of the two runs. The second head differs from the first by the name of one boolean and by added tests.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the fix narrows an optimization's precondition so a windowed Any::Blob falls through to the same to_string/to_array_buffer_view/to_json path that already handles it correctly whenever refcount > 1.

Reviewed: the new guard's types (offset/size/bytes.len() are all SizeType = u64, so the < comparison is sound); the let ... else refactor is behavior-preserving for every non-matching arm; the immutable bytes borrow ends at the len() read before data_mut() is called. Confirmed ByteBlobLoader::to_any_blob is the producer that sets offset/size = remain on the windowed path (line 158-159), matching the guard. Checked the >= rationale against shared_view's clamping — an oversized size still views the whole store, so the optimization stays enabled for slice(0). The 21 tests cover the source×consumer matrix plus nested/empty/unsliced controls; the FinalizationRegistry + gcTick poll loop is the harness-standard pattern for waiting on finalization.

Extended reasoning...

Overview

One-function change in src/runtime/webcore/Blob.rs: Any::to_internal_blob_if_possible now also requires offset == 0 && size >= bytes.len() before moving a Bytes store's whole buffer into an InternalBlob. The nested if let chain is refactored to let ... else early returns. A new 21-case describe block in test/js/web/fetch/blob.test.ts covers 2 stream sources × 8 buffered consumers, plus Response.text() after .body, a nested slice, an empty slice, and typed/untyped unsliced controls.

Security risks

None. This is a data-correctness fix in Blob stream consumption. No auth, crypto, permissions, or untrusted-input parsing is touched. The change is purely additive to a guard — it makes an optimization apply in strictly fewer cases, and the fallback is the existing windowed-Blob path (shared_view_raw) that already runs whenever another reference keeps the store alive.

Level of scrutiny

Native runtime code touching a refcount-gated buffer-adoption path, so I traced the types and the fallback carefully. SizeType is u64 (src/jsc/webcore_types.rs:32); Blob.offset/Blob.size are Cell<SizeType> (webcore_types.rs:121-122); Bytes::len() returns SizeType (webcore_types.rs:730) — the comparison is well-typed with no truncation. The fix is at the layer that owns the invariant (to_internal_blob yields the whole buffer, so the caller must prove the Blob views the whole buffer). When the guard rejects, to_action_value hits the Any::Blob arms (lines 6418-6450), which are exactly what runs today when has_one_ref() is false — a proven-correct path. No new allocation, refcount, or lifetime edge is introduced; the store still drops via the *self = ... replace when the optimization does apply, and via the stream detaching its Any when it doesn't.

Other factors

The PR description documents fail-before/pass-after (USE_SYSTEM_BUN=1, 17/21 fail across 12 runs; all 21 pass on the build) and lists a dozen adjacent test files that still pass. No CODEOWNERS cover these paths. The bug hunting system found nothing. The >= (not ==) choice is justified by shared_view's clamping and preserves the optimization for slice(0) and unsliced Blobs. The test's while (collected < tracked) await gcTick() is an unbounded loop in form, but gcTick forces a full GC and yields, and the tracked Blobs are unreachable once make returns — the file's default timeout backstops it. Tests were placed in the existing blob.test.ts per repo convention.

…ence

Any::to_internal_blob_if_possible turned any Blob over a uniquely owned
bytes store into an InternalBlob holding the whole buffer, ignoring the
Blob's offset and size. A stream made from blob.slice() reaches it with
exactly that shape once the parent and slice objects have been collected,
so Bun.readableStreamToText() and the other buffered consumers returned
every byte of the parent instead of the slice. Only convert when the Blob
views the whole store; otherwise the Blob read paths apply the window.
Since #42116 a Response or Request keeps a Blob-backed stream as its body,
so text() reads that stream through the buffered fast path. Cover
new Response(slice.stream()), new Response(response.body) and
new Request(url, { body: response.body }), and the stream of
new Blob([slice]).
@robobun
robobun force-pushed the farm/20904d2f/blob-slice-stream-whole-store branch from 1146abb to 412cca9 Compare October 2, 2026 05:29
Comment thread src/runtime/webcore/Blob.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/js/web/fetch/blob.test.ts
The guard reads as one named condition under a one-line comment.

The window table reads a prefix, a prefix of a prefix, a suffix, a slice
of a slice and two empty slices after their Blobs were collected, so
each clause of the guard has a case that depends on it.
@robobun

robobun commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:13 PM PT - Oct 2nd, 2026

@dylan-conway, your commit 68bbc7c is building: #122988

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

The prefix-window cases added in 255bd1a (test/js/web/fetch/blob.test.ts windows matrix) now make the size >= len clause of the guard load-bearing, which closes my earlier comment. Beyond the inline findings, I also checked the let-else rewrite of to_internal_blob_if_possible against the old nesting — same three guards plus the new window check, single effect on the same path — and that blob.size.get() >= bytes.len() behaves correctly for the unset-size sentinel and an empty store, so an unsliced Blob still takes the fast path.

Extended reasoning...

The change narrows the zero-copy fast path in src/runtime/webcore/Blob.rs so a windowed Blob slice stays an Any::Blob and is read through the offset/size-respecting paths; it touches no security-sensitive surface beyond returning fewer bytes than before. The new test matrix in test/js/web/fetch/blob.test.ts covers sources x consumers, body text() reads, and edge windows including the prefix case I asked for last run. Inline findings from this run (a pre-existing same-class is_ascii flag issue and a comment nit) already signal that a human should look, so this body only records what was additionally ruled out.

Comment on lines +613 to +618
// A slice shares its parent's backing store. Once the parent and the slice
// objects have been collected, a stream made from the slice holds the only
// reference to that store, and the buffered consumers (Bun.readableStreamTo*,
// stream.text(), text() of a Response or Request whose body is that stream)
// take a fast path that hands the store itself to JS. That path used to ignore
// the slice's offset and size and return every byte in the store.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): Maintainers reading this suite get a six-line comment that narrates the old bug ("That path used to ignore the slice's offset and size and return every byte in the store") rather than what the code cannot say. The repository guidance asks for one-line comments that never narrate the change and prefers a GitHub issue link. Fix: cut the block at test/js/web/fetch/blob.test.ts:613-618 to one line stating the invariant (a stream made from a slice must yield only the slice's window once the Blob objects are collected) plus the issue link, and shorten the sibling block at :693-694 the same way.

Why this was flagged

The new describe block at test/js/web/fetch/blob.test.ts:619 is preceded by a comment at :613-618 that describes the pre-fix behaviour of Any::to_internal_blob_if_possible and the fast path it took, i.e. it narrates the change rather than a fact the test code cannot express. The repository review instructions state comments must be one line, never narrate the change, and prefer links to GitHub issues. No runtime behaviour is affected; on the base branch neither the comment nor the tests exist. A fix keeps the tests and reduces the comment to the invariant under test and an issue reference.

Verification: nit. The comment exists at test/js/web/fetch/blob.test.ts:613-618. Its final sentence, "That path used to ignore the slice's offset and size and return every byte in the store.", narrates the pre-fix behaviour of Any::to_internal_blob_if_possible, and the block is six lines with no issue link. No runtime behaviour is affected; neither the comment nor the tests exist on the base.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, the block will become one line that states the invariant. I hold that edit so that the head that was checked does not move for a comment. It goes in with the next push to this file. There is no issue to link, the report came from a fuzz run.

Comment thread src/runtime/webcore/Blob.rs
@dylan-conway
dylan-conway merged commit e29a7ca into main Oct 2, 2026
9 of 10 checks passed
@dylan-conway
dylan-conway deleted the farm/20904d2f/blob-slice-stream-whole-store branch October 2, 2026 23:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants